Repository navigation
fork: follow each stable upstream release with the patch series - #4
Conversation
|
lens: ops+security DI-1 · reviewer: opus high (t3lens-di1) · read at: cd488d4 |
|
lens: ops+security DI-2 · reviewer: opus high (t3lens-4-di2) · read at: cd488d4
|
cd488d4 to
d92e842
Compare
|
fix round 1: rebased onto #3's new head (head No change to this patch (range-diff identical). The follow-mode schema check over 🤖 Generated with Claude Code |
f0c1027 to
c21e315
Compare
|
Fix round 1 for the lens reviews DI-1 (6819901664) and DI-2 (6820054224)
Findings
I did not take the deploy-key alternative (DI-2): the plan and Report also need API calls, and a deploy key cannot make them. State question: one test per row
Unproven
|
|
lens: ops+security DI-1 · reviewer: opus high (t3lens-4-di1-r2) · read at: 62216fc
|
|
lens: ops+security DI-2 · reviewer: opus high (t3lens-4-di2-r2) · read at: 62216fc
|
|
Fix round 2 · head Answers DI-1 r2 (comment 6822548258) and DI-2 r2 (comment 6822734464).
Tests.
State question. What happens when this runs again, stops half-way, or meets existing, old or empty state?
Unproven.
|
|
lens: ops+security DI-1 r3 · reviewer: opus high (t3lens-4-di1-r3) · read at: b898efc covered: full fix diff 62216fc..b898efc (fork-follow.yml, fork-follow-guard.sh, fork-follow.sh, fork-workflows.sh, fork-follow-report.sh, fork-follow.test.sh) plus what it calls (fork-follow-push.sh check/candidate/cleanup, fork-schema-check.cjs exit codes). Every FORK_PUSH_TOKEN reference across .github/workflows (only plan and advance, both environment: fork-push; release passes signing secrets by name, no inherit). Repo state read-only: no Environments exist yet; a repository secret named FORK_PUSH_TOKEN exists (name only, value not read). checks:
finding: minor .github/workflows/fork-follow.yml:100 "Re-run failed jobs" (or re-run of Plan/Move alone) reuses the guard's earlier success: guard ok, plan fails (say an expired token); the owner then deletes the Environment or adds a repo secret, and re-runs failed jobs days later (allowed for 30 days) -> plan starts without a re-check, GitHub auto-creates fork-push with no branch rule, and the token falls back to the repo secret, the exact state the guard exists to refuse. The next fresh run catches it, so this is a gap in one run, not a lasting hole. Fix: guard outputs attempt=${{ github.run_attempt }}; plan and advance get |
|
lens: ops+security DI-2 r3 · reviewer: opus high (t3lens-4-di2-r3) · read at: b898efc covered: the full fix diff 62216fc..b898efc (fork-follow-guard.sh, fork-follow.yml, fork-follow.sh, fork-workflows.sh, fork-follow-report.sh, fork-follow.test.sh) plus what it calls: fork-follow-push.sh check/candidate/advance/cleanup, fork-schema-check.cjs (only node: builtins, runs no upstream code). Ran bash .github/scripts/fork-follow.test.sh and node --test fork-schema-check.test.cjs at both commits: 62216fc has 44 ok + 25 pass, b898efc has 70 ok + 25 pass, all green. Broke it by hand, one change at a time: guard accepts REPO_SECRET_PRESENT=true -> red; plan without needs: [guard] -> red; "protected branches only" accepted -> red; rule check loosened to "contains branch main" -> red; presence hard-coded to false -> red; guard job given environment: fork-push -> red (2 checks). Two changes stayed green (finding 3). Live state, read-only: repo secret FORK_PUSH_TOKEN exists, no Environments, no runs of fork-follow.yml, 17 workflows registered. checks:
finding: minor .github/workflows/fork-follow.yml:100 - "Re-run failed jobs" or "Re-run job" after the plan or advance failed reruns plan, release and advance, but reuses the guard's success from attempt 1. A repo secret re-added, or the rule widened, since then, perhaps days later, is not seen, and plan or advance gets the token anyway. The same applies to advance, which gets the token after release, often an hour or more after the guard looked. Impact: the alarm comes one run late; the token's exposure itself is decided by GitHub's settings either way. Fix: add attempt=$GITHUB_RUN_ATTEMPT to the guard's outputs, with a first step in plan and advance that refuses unless needs.guard.outputs.attempt == github.run_attempt ("re-run all jobs"). Optionally also repeat check 2 (the Environment rule) there. (high) finding: minor .github/scripts/fork-workflows.sh:83 - in this repo today, 12 upstream files on main are not registered: desktop-macos-preview, issue-labels, mobile-android-apk, mobile-eas-preview-fork, mobile-fingerprint-label, mobile-showcase-screenshots, publish-aur, release-desktop, release, stale-needs-more-info, thread-transfer-report and windows-tests. Several of them can never be registered by an event the fork sends: workflow_call-only (release-desktop, publish-aur), workflow_dispatch-only (windows-tests, mobile-showcase-screenshots) and workflow_run-only. So every run, green ones included, prints the bold row "not disabled (the sweep after their first run disables them)" with about 12 files, and a ::warning::. For these files that promise is untrue, and a genuinely new push-, pull_request_target- or schedule-triggered upstream file, the case the row exists for, is buried in permanent noise. Fix: name only files new since the series' previous base, i.e. git diff --diff-filter=A previous_head..TREE_REF, or only files whose on: can fire in the fork. Computed read-only against the live repo. (high) finding: minor .github/scripts/fork-follow.test.sh:546 - adding "if: always()" (or "!cancelled()") to the plan job keeps the suite green, yet it lets the first job with the token run after the guard refused. The check greps only the needs: line. Likewise, deleting the SERIES_BRANCH wildcard refusal at fork-follow-guard.sh:81 stays green, because no test sets FORK_SERIES_BRANCH to a pattern. Fix: assert that the plan block has no if: (or only success()-implying ones), and add a guard case with SERIES_BRANCH='main*' and rule 'branch main*'. Both mutations were run by hand. (high) |
|
fix round 3 · head fc08057 · range b898efc..fc08057 (1 commit) · answers DI-1 r3 (6823109924) and DI-2 r3 (6823151991) Fixed
Tests. State question: what happens when this runs again, stops half-way, or meets existing, old or empty state?
Unproven
|
|
lens: quick check r4 · reviewer: opus high (t3lens-4-r4) · read at: fc08057 covered: the full fix diff b898efc..fc08057 (fork-follow.yml, fork-follow-guard.sh, fork-workflows.sh, fork-follow.sh, fork-follow-report.sh, fork-follow.test.sh) and what it calls into: the plan's new_workflows path (base -> target, also on action advance), the sweep's listed/contents queries, the guard's Environment and rules reads, and fork-follow-push.sh as reached from advance. checks:
finding: minor .github/scripts/fork-follow.sh:159. new_workflows reads finding: minor .github/scripts/fork-follow.sh:159. Because of --diff-filter=A, an upstream file that already existed at the old base, is unlisted by GitHub, and is changed on this release (say it gains finding: minor .github/scripts/fork-follow.test.sh:607-613. Three mutations of the fixes leave all 87 tests green: |
fork-follow.yml runs daily. When upstream has a newer stable tag it rebases the series onto it (patches upstream now has drop out as empty), runs the schema check, tests, builds and publishes through fork-release.yml, then moves the series branch with a lease. When the series has commits no fork release has, it releases them. A conflict, schema change, upstream commits inside the series, a red test or a failed build open or update one issue and publish nothing. Pushing a rebase needs a FORK_PUSH_TOKEN secret, because GITHUB_TOKEN may not push commits that change workflow files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ws off Review round 1 on the follow-upstream workflow. - The plan picks one of five actions: none, release, rebase, advance (the rebased series is already released; only move main) and stage (the rebase changes the release workflow itself, so the candidate is pushed and a person starts its release). - Merge commits on main are refused instead of being flattened by a rebase; deleted or moved upstream tags and failed fetches give a reason and the right advice. - All pushes go through fork-follow-push.sh with FORK_PUSH_TOKEN only. Every run checks the token first (missing, refused, no push access). Moving main uses a lease, treats "already there" as success, and first keeps the old series as tag fork-follow/pre-<tag>-<sha12>. - fork-workflows.sh disables upstream's workflows and cancels their runs after each push and at the end of the run. - The report job runs on every run and reads branches, tags and releases back from the repository instead of guessing from job results. Its summary is the report; an issue is opened, commented on or closed only when the repository has Issues enabled. - fork-follow.test.sh covers all of this against local git remotes and a fake gh, plus static checks on the workflow file (secrets passed by name, token only in push steps, persist-credentials false, pinned actions). The plan job runs it first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A repository secret can be read by a workflow file on any pushed branch. An Environment secret limited to the series branch reaches only runs from that branch, so the plan and advance jobs (the only ones that read FORK_PUSH_TOKEN) now run in the Environment fork-push. When the token is empty there, the check fails and its reason (in the run's summary) names the Environment, how to limit it to the series branch, and why the token should not stay a repository secret. A test checks that exactly the jobs that read the token use the Environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pin down two state cases from review round 1: upstream without a stable tag, or a series that contains none, is refused with a reason and plans nothing; a refused candidate push leaves git's reason in the report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n every path The Guard job (no Environment) runs before any job gets FORK_PUSH_TOKEN and refuses unless the repository holds no repository or organisation secret of that name (it sees only whether `secrets.FORK_PUSH_TOKEN` is empty there) and the Environment fork-push exists with exactly one deployment rule: the branch main. Each refusal says what to change. The plan compares the release workflows of the run's commit with the candidate's on the release path too, so a re-run or queued run of an older commit stages instead of building with other release steps. A commit missing from the fork is refused. The sweep cancels unfinished upstream runs from any event, names upstream runs that finished since the run began, and names upstream workflow files GitHub does not list yet. The report shows both. Nits: a crashing schema check is reported as a crash; the conflict advice says to start the follow workflow after the manual push; `while read` instead of an unquoted `for`; the ERR trap reports only from the main shell; action none deletes a leftover candidate branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w upstream workflow is unlisted "Re-run failed jobs" and "Re-run job" keep the Guard job's result from an earlier attempt without running it again. The guard now outputs the attempt it passed in (set only after the guard script), and plan and advance run only when that is this attempt: a job-level if, so a skipped job never reaches the Environment. The report says to re-run all jobs. Before the branch move, advance checks the Environment's rule again (fork-follow-guard.sh --environment-only), because the release can take an hour or more. The Environment ties the token to the branch main, not to fork-follow.yml, so any workflow file on main may ask for it. The plan names the upstream workflow files that are new on the target release (a rename counts as new). Advance sweeps before the push, and refuses to move main while one of them is in the candidate and GitHub does not list it, since it cannot be disabled first. The reason says how a person moves main, or that a fork patch can delete the files. The sweep names only the new files as unregistered (with a warning). Unlisted files from earlier releases, most of which only run on events the fork never sends, go to unregistered_old and into a collapsed note in the summary. Tests: the plan's only job-level if is this attempt's guard (always(), !cancelled() and failure() turn it red); advance needs this attempt's guard; a pattern series branch with the matching rule is refused; new vs old unlisted files; the refusal before the move; the report on a stale guard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A test run against other scripts made a real gh API sweep and disabled a workflow of the live fork. Now every script that calls gh sources fork-gh-guard.sh: outside a GitHub Actions run it refuses unless a test runs it (FORK_TEST_MODE) with the stub as the gh on PATH, no GitHub token and a .invalid GH_HOST. It exits before any gh call, and every later gh call checks again. The tests install the stub through fork-test-gh.sh, which also empties the gh config, so even a script without the guard finds nothing to log in to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fork-release.sh sources fork-gh-guard.sh like the follow scripts, and fork-release.test.sh installs its stub through fork-test-gh.sh, with tests that a run without the stub, by hand, with a token or with a real GH_HOST refuses before any gh call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sweep took only the files the series adds as the fork's own, so a fork pull request that adds a fork-*.yml had that workflow disabled (and its runs cancelled) by the next sweep. Now every .github/workflows/fork-*.y[a]ml is the fork's own. In return the plan refuses an upstream release that has such a file and the candidate keeps it, since the sweep would never disable it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
new_workflows read `git diff --name-only` with git's quoting, so a new upstream workflow with a non-ASCII name came out quoted, failed the .yml filter and slipped past the branch-move refusal. It now reads the diff NUL-separated with core.quotepath=off, and refuses a name with ',' or a newline (the lists are ','-joined). It also takes changed files (--diff-filter=AM): an unlisted upstream workflow that now runs on push holds the move like a new one. The lists are built in the plan's own shell, so a refusal stops the plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…usal Three breaks left every test green: `||` after the attempt check in advance's if:, and continue-on-error on the Environment re-check or on the sweep before the move (the refusal turns into a warning and the branch moves). The tests now read advance's if: as one conjunction (no `||` outside parentheses) and allow continue-on-error only in the Report job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gitleaks 8.30.1 logs `ERR error="stderr is not empty"` when git writes to stderr while it reads the log, may drop the rest of the diffs, and exits 0. The scan now fails (2) on any ERR line in gitleaks' log, before the commit count. Test: a git wrapper that writes a stray stderr line; with a 3,000-file commit the real gitleaks misses a key in the last file and passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fc08057 to
c861d84
Compare
Fix round 4New head c861d84, pushed with RebaseI rebased #4's own six commits onto main
Fixes:
|
| # | Commit | Change | Red before, green after |
|---|---|---|---|
| 1 | 8deff417c (fork-follow scripts)51af29d39 (fork-release, #2's files) |
The scripts that call gh source the new fork-gh-guard.sh. Every gh call goes through a gh() function that checks again each time. gh is allowed only in a GitHub Actions run (GITHUB_ACTIONS=true, not test mode), or in test mode (FORK_TEST_MODE) under these conditions: the gh on PATH is the stub FORK_GH_STUB names, it carries the stub's marker line, no GitHub token is set, and GH_HOST is a .invalid host. Anything else exits 1 before the first gh call. The tests set this up first with fork-test-gh.sh: they unset the tokens, set GH_HOST=gh-stub.invalid, give gh an empty config, put the stub first on PATH, and stop if gh resolves anywhere else. |
fork-follow.test.sh, 6 tests: gh: a test run without the stub first on PATH fails closed and never calls gh; with GH_TOKEN set; with GITHUB_TOKEN set; a gh named as the stub but without the marker; PATH changed after the start (each call checks again); and run by hand.fork-release.test.sh, 4 tests: without the stub; with a token set; run by hand (no test mode, not in a run); the per-call re-check. |
| 2 | 2e10b37a6 |
In fork-workflows.sh, own() also counts any .github/workflows/fork-*.yml or .yaml as the fork's own, even when main doesn't have the file yet. The plan refuses a release where upstream itself ships a fork-*.yml that the candidate keeps, so upstream can't hide behind the name. |
workflows: a fork-*.yml/.yaml absent from main (a fork PR adds it) is never disabled, its runs never cancelledplan: rebasing onto a release with an upstream fork-*.yml is refused and names itplan: same base with an upstream fork-*.yml is refused tooworkflows: an upstream name that only contains 'fork' is still disabled (this one was a control and stayed green) |
| 3, 4 | 717ea028d |
fork-follow.sh reads the workflow diff with -z and core.quotepath=off, and refuses names that contain , or a newline. It uses --diff-filter=AM, so a changed upstream workflow holds the move just like a new one. Both lists are built in the main shell, so a refusal stops the plan. The message now says "new or changed on this release". |
plan: a changed upstream workflow and a non-ASCII new one are both named, unquotedplan: a new upstream workflow with ',' in its name (cannot be listed) is refused, nothing plannedthe matching workflows: sweep test |
| 5 | c3ee35461 |
Two new workflow tests. I checked them against three mutants of fork-follow.yml: one with || in advance's if:, one with continue-on-error on the Environment re-check, and one with it on the refusal step. Each mutant turns a new test red, while the old tests stayed green on all three. |
workflow: advance's if: is a conjunction (no top-level ||) of the plan's success and this attempt's guardworkflow: continue-on-error appears only in the Report job (never on the guard, the re-check or the sweep before the move) |
| 6 | c861d8427 (#6's files, kept as a separate commit) |
fork-secret-scan.sh fails with exit 2 when gitleaks exits 0 but logs an ERR line, such as git stderr or ERR error="stderr is not empty". This check runs before the commit-count check. The stand-in gitleaks now copies git's stderr the way 8.30.1 does. |
pull request: gitleaks logging a git error (stderr not empty) and passing fails (red with the stand-in).pull request: a stray git stderr line that hides a key in a wide commit fails: one commit with 3,000 files and a key in the last one, plus a git wrapper that writes one stray stderr line. Before the fix, real gitleaks 8.30.1 printed "no leaks found" and the scan exited 0. Now it exits 2.Control: without the stray line the same scan passes (0). |
Full fork test set (local, all tokens unset, GH_HOST .invalid)
fork-follow.test.sh: 104 okfork-release.test.sh: 38 okfork-secret-scan.test.sh: 18 ok with the stand-in, and green with real gitleaks 8.30.1fork-release-workflow.test.py: all passednode --test:fork-schema-check.test.cjs47/47,fork-ci-tests.test.cjs8/8,fork-test-shards.test.cjs4/4,fork-tests-drift.test.cjs6/6node fork-ci-tests.cjs: 0
None of this PR's scripts were run against the real GitHub API.
CI on c861d84
Not green, and both failures come from main, not from this PR. Checked at 13:3x UTC:
- Passing: Build and Typecheck, Rust, Release Smoke, Secret scan, Workflow lint and upstream drift (where the fork shell tests run), and Test Server 1 to 8.
- Still pending: Test 1, Test 2, Test Web 1, Test Web 2.
- Lint fails: 8 ×
t3code(no-low-value-comments), all in.github/scripts/fork-schema-check.cjs, which is fork: CI check that refuses database schema and migration changes #3's file. This PR doesn't touch it. Mainae0d6865fails Lint the same way in its own push run (43223856243). - No database schema changes fails in
actions/checkoutof the default branch, before any script runs:fatal: No url found for submodule path '.repos/alchemy-effect/submodules/distilled' in .gitmodules. Upstream's tree has gitlinks under.repos/alchemy-effect/submodules/but no.gitmodules. The job ispull_request_target, so it runs main'sfork-checks.yml, and a fix in this PR can't reach it. It needs a fix on main, either infork-checks.ymlor in fork: CI check that refuses database schema and migration changes #3's lint comments. I left both alone because they are outside fork: follow each stable upstream release with the patch series #4's files.
State question: what happens on a rerun, a run that stops half-way, or old, existing or empty state?
One test per row.
| Case | What happens | Test |
|---|---|---|
| Rerun: advance after it moved | It succeeds without pushing; the series is already there. | push: advance again after it moved (re-run) succeeds without a push |
| Rerun: the sweep | Workflows that are already disabled are left alone, and finished or cancelled runs aren't touched again. | workflows: an active upstream workflow is disabled and named; own, disabled and dynamic ones are leftworkflows: upstream runs that finished before the sweep are named …; own and cancelled ones are not |
| Rerun: the report alone after a good run | No false alarm. | report: a re-run of the report alone after a good run does not cry wolf |
| Rerun: the guard passed in an earlier attempt and the plan skipped | It fails and says to re-run all jobs. | report: the guard passed in an earlier attempt, the plan skipped -> fails, says to re-run all jobs |
| Rerun: release, re-running failed jobs | It refuses to remove drafts for a version whose tag exists. | remove-drafts: refuses a version whose tag exists (re-run failed jobs) |
| Half-applied: published but not moved | The report says both, and never says "nothing was published". A later run advances to the published release without building again. | report: published but not moved -> says both, never 'nothing was published'plan: a published release of the rebased files exists -> advance to it, build nothing |
| Half-applied: a failed release with no tag | The report says "none published", read from the repository. | report: a failed release with no tag -> 'none published', read from the repository |
| Old state: an older test file or a run by hand | The scripts refuse to start without test mode and a real Actions run, so an old test file that runs these scripts can no longer reach gh. | gh: run by hand (no test mode, not in a GitHub Actions run) refusesgh: a test run without the stub first on PATH fails closed and never calls gh |
| Existing state: a fork-*.yml only on a PR | It is never disabled and its runs are never cancelled. | workflows: a fork-*.yml/.yaml absent from main (a fork PR adds it) is never disabled, its runs never cancelled |
| Old state: unlisted upstream files from earlier releases | They go in a collapsed note and don't refuse the move. | report: unlisted upstream files from earlier releases go in a collapsed note, not a bold row, and passworkflows: before the move, a new upstream file GitHub lists (now disabled) and older unlisted ones do not refuse it |
| Old state: a stray higher tag or an older ref | Refused. | resolve: refuses a stray higher tag on an old commitresolve: refuses a build below a published release |
| Empty: tagless upstream history | Refused with a reason; nothing is planned. | plan: upstream has no stable tag -> refused with a reason, nothing planned |
| Empty: an empty own list | Nothing is disabled; it refuses to guess. | workflows: an empty own list disables nothing (refusing to guess) |
| Empty: a failed workflow listing | It fails with a reason. | workflows: a failed listing fails with a reason |
Not proven
- Old test file plus old scripts. A checkout from before this round, running its own test file against its own scripts, has neither guard. Nothing in this PR can change an older checkout. Since this round, the scripts refuse unless test mode is set up, and the tests set up the stub, the
.invalidhost and the empty config before any script runs. - The guard only checks the run's environment. It can't tell a real fork workflow from any other GitHub Actions run of these scripts, so it relies on
GITHUB_ACTIONS=true. - Fix 6 is tested only against gitleaks 8.30.1. The
ERRmatch assumes its log format, with or without a timestamp beforeERR. Another version could log errors differently. - The mutants for fix 5 were checked locally. They are not kept as a test.
|
lens: correctness+security r5 · reviewer: opus high (t3lens-4-r5) · read at: c861d84 covered: the rebase (range-diff, and whether the dropped commits are in main); the full fix diff 70b9612..c861d84; everything it calls into: fork-gh-guard.sh, fork-test-gh.sh, every gh call in fork-workflows.sh, fork-follow-guard.sh, fork-follow-report.sh and fork-release.sh, fork-follow-push.sh (git only, no gh), the workflow_paths/fork_named/refuse_upstream_fork_named functions in fork-follow.sh, own() in fork-workflows.sh, the ERR check in fork-secret-scan.sh, and which test steps in fork-follow.yml, fork-release.yml and fork-tests.yml run with a token. checks:
finding: minor .github/workflows/fork-follow.yml:98 (also :289 and :305): adding "|| true" to the guard's run line (or to the re-check at :289, or to sweep_before's fork-workflows.sh at :305) leaves fork-follow.test.sh green, 104/104 (I tried all 3). With it on :98, the guard job succeeds after a refusal, the "passed" step sets the attempt, and plan and advance get FORK_PUSH_TOKEN. The new continue-on-error test guards only one of the two ways to swallow a refusal. This is a gap for a future edit, not a bug at this head. Suggested fix: assert that each refusal step's run is exactly the script line (no ||, no "set +e") (high) |
Keeps the fork's patch series on upstream's newest stable release and publishes it with no one stepping in. When something needs a person, the run fails and its summary gives the reason.
fork-follow.ymlruns daily at 06:23 UTC, and can also be started by hand. A Guard job runs first (see below). Then.github/scripts/fork-follow.shplans one of five actions:nonev<tag>-fork.Nalready points at the series headfork-follow/candidatebranch is deleted. The report closes an open follow issue.releasefork-release.yml(#2) builds and publishes the head as it is.rebasevX.Y.Z, no nightly or preview)fork-follow/candidate, it is released, and the series branch moves to it.advancestagefork-release.ymland every workflow it calls) of the run's commit differ from the candidate's. That happens when upstream changed them, or on a re-run or queued run of an older commit. It is checked on both thereleaseand therebasepath.gh workflow run fork-release.yml --ref fork-follow/candidate …command. After that release, the next follow run finds it (and advances to it).The plan refuses, with a reason, in these cases:
Guard: the token's isolation is checked, not assumed.
.github/scripts/fork-follow-guard.shruns in the Guard job, which has no Environment. It runs before any job that getsFORK_PUSH_TOKEN, and it refuses in these cases:FORK_PUSH_TOKENexists. A workflow file on any pushed branch can read such a secret, and a job in an Environment falls back to it when the Environment has none. Outside an Environment,secrets.FORK_PUSH_TOKENcan only be such a secret. So the job passes onlysecrets.FORK_PUSH_TOKEN != ''(true/false), never the value.GITHUB_TOKENhas no permission that lists secrets, so this is the check GitHub allows.fork-pushdoes not exist, or it allows any branch, or it allows "Protected branches only". The API cannot prove that this means onlymain.main, with no pattern and no tag rule. The guard reads them withGITHUB_TOKEN(actions: read).Each refusal says what to change under Settings. A refusing guard also means the plan job never runs, so GitHub does not auto-create an empty
fork-pushEnvironment.The guard counts for one run attempt only. "Re-run failed jobs" and "Re-run job" keep the Guard job's earlier result without running it again, and the settings may have changed since. The guard outputs the attempt it passed in, set by a step after the guard script. Plan and the branch move run only when that is the current
github.run_attempt. The check is a job-levelif:(with the impliedsuccess()), so a skipped job never reaches the Environment. The summary then says to use "Re-run all jobs" or start a new run. Right before the branch move, which can come an hour or more after the guard, the move job checks the Environment's rule again (fork-follow-guard.sh --environment-only). The repository secret cannot be checked from inside the Environment.Pushes. Every push goes through
.github/scripts/fork-follow-push.shwithFORK_PUSH_TOKEN. Nothing falls back to another token.fork-follow/pre-<tag>-<sha12>. Then it pushes with a lease against the planned head. If the branch is already at the target (a re-run), that counts as success. If the branch moved elsewhere (a fork PR merged mid-run), the push is refused, and the next run rebases again with that change.GIT_CONFIG_*. It is never in a URL,.git/configor an error message. Every checkout usespersist-credentials: false.secrets: inherit. Every action is pinned by commit SHA.Upstream workflows stay off.
.github/scripts/fork-workflows.shruns after the candidate push, right before the branch move, after it, and again in the report. Each time it:.github/workflowsthat the series did not addpull_request,pull_request_target, schedule …) and for any commitnew_workflows: added between the old and the new upstream base; a rename counts as new) get a warning and their own row.workflow_call,workflow_dispatch,workflow_run), so they stay unlisted. In this fork today that is about 12 files.The summary shows finished runs and new unlisted files as their own rows.
The branch move waits for new upstream workflows. The Environment ties the token to the branch
main, not tofork-follow.yml, so any workflow file onmainmay ask forfork-push. Movingmainalso fires its push workflows before a later sweep can stop them. So the move job sweeps first, and refuses to movemainwhile an upstream workflow file that is new on this release is in the candidate and GitHub does not list it. The reason names the files and leaves it to a person: read them; if they are harmless, movemainby hand (the command is given) and start the follow workflow right after; otherwise add a fork patch that deletes them. Until then each run plansadvanceand refuses again.Report. The Report job runs on every run. It reads the series branch, the candidate branch, the backup tags and the releases back from the repository, rather than inferring them from job results. So it never says "nothing was published" after a release was published. The run's summary is the report, and the run fails whenever a person is needed. If the repository has Issues enabled, the job also opens or comments on one issue, "Fork cannot follow upstream", and closes it on the next good run. If Issues are disabled, it says so and opens nothing.
Owner setup before this can run
fork-push(Settings > Environments > New environment).main. Add nothing else.FORK_PUSH_TOKENthere: a fine-grained token for this repository only, with Contents and Workflows read and write.GITHUB_TOKENcannot replace this token, because it may not push commits that change workflow files.FORK_PUSH_TOKEN(Settings > Secrets and variables > Actions > Repository secrets). It exists today, and while it does, the guard refuses every run.maingets branch protection or a ruleset, allow the token's user to bypass it. Otherwise the branch move is refused, and the next run takesadvanceagain.mainmust be a patch series:v0.1.9plus the fork's own commits, with no merge commits. Today it equals upstreammain, and the plan refuses that state and says so. Right after force-pushing the rebuiltmain, start the workflow once (gh workflow run fork-follow.yml --repo scuttletoby/t3code). That push fires upstream'spush: mainworkflows, and the run disables them, cancels their runs and names the ones that already finished.Today only the fork's own workflows are active (
fork-checks,fork-release,fork-tests).Merge order. Merge #2 (
fork-release.yml) and #6 (fork-tests.yml, which #2 calls) first. Merged before them, this workflow file is invalid, and every run fails at load with no report. This branch sits on #3 (the schema check).Follow-up, not in this PR.
stageneeds a person each time upstream changesrelease-desktop.yml, which happened in 4 of the last 7 upstream releases. The follow job could startfork-release.yml --ref fork-follow/candidateitself (workflow_dispatch withGITHUB_TOKENandactions: write), and the next run would advance as it does now. This is left for a separate change (DI-1 round 2, minor 4).Tested.
.github/scripts/fork-follow.test.shhas 87 cases. The plan job runs it before anything else. The cases cover:ghstageon the release path for a re-run of an older commit--environment-onlypassing without the secret answer and still refusing an open Environmentnew_workflows: a file added and a file renamed on the target release; none on the same basehead_shaandstatusas GitHub does. Covered: runs from other events and commits are cancelled; finished runs are named, but not own or cancelled ones; unlisted files new on the release are named with a warning, older ones apart without one; before the move, a new unlisted file refuses it (after the listed ones are disabled), while a listed new file or older unlisted ones do not.fork-pushjobs!= ''if:is this attempt's guard (always(),!cancelled()orfailure()there turns it red)REQUIRE_NEW_OFF, and the move step has noif:noneactionlint 1.7.7 (with shellcheck) passes on this workflow next to
main'sfork-release.yml.Not tested
fork-release.ymlis not on this branch.github.run_attempt.on: pushnames onlymain(most likely not, so such a release needs a person each time).fork-pushwould still run once onmain.ls-remotefailpush --dry-runchecks push accesssecrets.FORK_PUSH_TOKEN != ''outside an Environment also sees an organisation secret (GitHub documents it; this repository is not in an organisation)can_admins_bypass.Production), plus the sweep's contents and runs queries.🤖 Generated with Claude Code